@testable import Sentry
import SentrySampleShared
import XCTest

//swiftlint:disable function_body_length todo

class ProfilingUITests: BaseUITest {
    override var automaticallyLaunchAndTerminateApp: Bool { false }
    
    func testAppLaunchesWithContinuousProfilerV2TraceLifecycle() throws {
        guard #available(iOS 16, *) else {
            throw XCTSkip("Only run for latest iOS version we test; we've had issues with prior versions in SauceLabs")
        }

        try performTest(lifecycle: .trace)
    }
    
    func testAppLaunchesWithContinuousProfilerV2ManualLifeCycle() throws {
        guard #available(iOS 16, *) else {
            throw XCTSkip("Only run for latest iOS version we test; we've had issues with prior versions in SauceLabs")
        }

        try performTest(lifecycle: .manual)
    }
}

extension ProfilingUITests {
    enum Error: Swift.Error {
        case missingFile
        case emptyFile
    }
    
    func marshalJSONDictionaryFromApp() throws -> [String: Any] {
        let string = try XCTUnwrap(app.textFields["io.sentry.ui-tests.profile-marshaling-text-field"].afterWaitingForExistence("Couldn't find data marshaling text field.").value as? NSString)
        if string == "<missing>" {
            throw Error.missingFile
        }
        if string == "<empty>" {
            throw Error.emptyFile
        }
        let data = try XCTUnwrap(Data(base64Encoded: string as String))
        return try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
    }
    
    func checkLaunchProfileMarkerFileExistence() throws -> Bool {
        app.buttons["io.sentry.ui-tests.app-launch-profile-marker-file-button"].afterWaitingForExistence("Couldn't find app launch profile marker file check button").tap()
        let string = try XCTUnwrap(app.textFields["io.sentry.ui-tests.profile-marshaling-text-field"].afterWaitingForExistence("Couldn't find data marshaling text field.").value as? NSString)
        return string == "<exists>"
    }
    
    func goToTransactions() {
        app.tabBars["Tab Bar"].buttons["Transactions"].tap()
    }
    
    func startTransaction() {
        app.buttons["startTransactionMainThread"].afterWaitingForExistence("Couldn't find button to start transaction").tap()
    }
    
    func stopTransaction() {
        app.buttons["stopTransaction"].afterWaitingForExistence("Couldn't find button to end transaction").tap()
    }
    
    func goToProfiling() {
        app.tabBars["Tab Bar"].buttons["Profiling"].afterWaitingForExistence("Couldn't find profiling tab bar button").tap()
    }
    
    func retrieveLastProfileData() {
        app.buttons["io.sentry.ui-tests.view-last-profile"].afterWaitingForExistence("Couldn't find button to view last profile").tap()
    }
    
    func retrieveFirstProfileChunkData() {
        app.buttons["io.sentry.ui-tests.view-first-continuous-profile-chunk"].afterWaitingForExistence("Couldn't find button to view first profile chunk").tap()
    }
    
    func stopContinuousProfiler() {
        app.buttons["io.sentry.ios-swift.ui-test.button.stop-continuous-profiler"].afterWaitingForExistence("Couldn't find button to stop continuous profiler").tap()
    }

    func performTest(lifecycle: SentryProfileOptions.SentryProfileLifecycle? = nil) throws {
        try launchAndConfigureSubsequentLaunches(shouldProfileThisLaunch: false, shouldProfileNextLaunch: true, lifecycle: lifecycle)
        try launchAndConfigureSubsequentLaunches(terminatePriorSession: true, shouldProfileThisLaunch: true, shouldProfileNextLaunch: false, lifecycle: lifecycle)
    }

    fileprivate func setAppLaunchParameters(_ lifecycle: SentryProfileOptions.SentryProfileLifecycle?, _ shouldProfileNextLaunch: Bool) {
        app.launchArguments.append(contentsOf: [
            // these help avoid other profiles that'd be taken automatically, that interfere with the checking we do for the assertions later in the tests
            SentrySDKOverrides.Other.disableSwizzling.rawValue,
            SentrySDKOverrides.Performance.disablePerformanceTracing.rawValue,
            SentrySDKOverrides.Performance.disableUIVCTracing.rawValue,
            SentrySDKOverrides.Performance.disableTimeToFullDisplayTracing.rawValue,

            // sets a marker function to run in a load command that the launch profile should detect
            SentrySDKOverrides.Profiling.slowLoadMethod.rawValue,
            
            // override full chunk completion before stoppage introduced in https://github.com/getsentry/sentry-cocoa/pull/4214
            SentrySDKOverrides.Profiling.immediateStop.rawValue
        ])

        app.launchEnvironment[SentrySDKOverrides.Profiling.sessionSampleRate.rawValue] = "1"
        switch lifecycle {
        case .none:
            fatalError("Misconfigured test case. Must provide a lifecycle for UI profiling.")
        case .trace:
            break
        case .manual:
            app.launchArguments.append(SentrySDKOverrides.Profiling.manualLifecycle.rawValue)
        }
        
        if !shouldProfileNextLaunch {
            app.launchArguments.append(SentrySDKOverrides.Profiling.disableAppStartProfiling.rawValue)
        }
    }
    
    /**
     * Performs the various operations for the launch profiler test case:
     * - terminates an existing app session
     * - starts a new app session
     * - sets launch args and env vars to set the appropriate `SentryOption` values for the desired behavior
     * - launches the new configured app session, which will optionally start a launch profiler and then call SentrySDK.startWithOptions configured based on the launch args and env vars
     * - asserts the expected outcomes of the config file and launch profiler
     */
    func launchAndConfigureSubsequentLaunches(
        terminatePriorSession: Bool = false,
        shouldProfileThisLaunch: Bool,
        shouldProfileNextLaunch: Bool,
        lifecycle: SentryProfileOptions.SentryProfileLifecycle?
    ) throws {
        if terminatePriorSession {
            app.terminate()
            app = newAppSession()
        }
        
        setAppLaunchParameters(lifecycle, shouldProfileNextLaunch)

        launchApp(activateBeforeLaunch: false)
        goToProfiling()

        let configFileExists = try checkLaunchProfileMarkerFileExistence()

        if shouldProfileNextLaunch {
            XCTAssert(configFileExists, "A launch profile config file should be present on disk if SentrySDK.startWithOptions configured launch profiling for the next launch.")
        } else {
            XCTAssertFalse(configFileExists, "Launch profile config files should be removed upon starting launch profiles. If SentrySDK.startWithOptions doesn't reconfigure launch profiling, the config file should not be present.")
        }

        guard shouldProfileThisLaunch else {
            return
        }

        if lifecycle == .manual {
            stopContinuousProfiler()
        }
        retrieveFirstProfileChunkData()

        try assertProfileContents()
    }

    func assertProfileContents() throws {
        let lastProfile = try marshalJSONDictionaryFromApp()
        let sampledProfile = try XCTUnwrap(lastProfile["profile"] as? [String: Any])
        let stacks = try XCTUnwrap(sampledProfile["stacks"] as? [[Int]])
        let frames = try XCTUnwrap(sampledProfile["frames"] as? [[String: Any]])
        let stackFunctions = stacks.map({ stack in
            stack.map { stackFrame in
                frames[stackFrame]["function"]
            }
        })

        // grab the first stack that contained frames from the fixture code that simulates a slow +[load] method
        var stackID: Int?
        let stack = try XCTUnwrap(stackFunctions.enumerated().first { nextStack in
            let result = try nextStack.element.contains { frame in
                let found = try XCTUnwrap(frame as? String).contains("+[NSObject(SentryAppSetup) load]")
                if found {
                    stackID = nextStack.offset
                }
                return found
            }
            return result
        }).element.map { any in
            try XCTUnwrap(any as? String)
        }
        guard stackID != nil else {
            XCTFail("Didn't find the ID of the stack containing the target function")
            return
        }

        // ensure that the stack doesn't contain any calls to main functions; this ensures we actually captured pre-main stacks
        XCTAssertFalse(stack.contains("main"))
        XCTAssertFalse(stack.contains("UIApplicationMain"))
        XCTAssertFalse(stack.contains("-[UIApplication _run]"))

        // ensure that the stack happened on the main thread; this is a cross-check to make sure we didn't accidentally grab a stack from a different thread that wouldn't have had a call to main() anyways, thereby possibly missing the real stack that may have contained main() calls (but shouldn't for this test)
        let samples = try XCTUnwrap(sampledProfile["samples"] as? [[String: Any]])
        let sample = try XCTUnwrap(samples.first { nextSample in
            try XCTUnwrap(nextSample["stack_id"] as? NSNumber).intValue == stackID
        })
        XCTAssert(try XCTUnwrap(sample["thread_id"] as? String) == "259") // the main thread is always ID 259
    }
}

//swiftlint:enable function_body_length todo
